Skip to content

S08-05 Node-Express

[TOC]

基础用法

认识 Express

HTTP 内置模块的缺点

前面我们已经学习了使用 http 内置模块来搭建 Web 服务器,为什么还要使用框架?

  • 使用复杂

    原生 http 在进行很多处理时,会较为复杂。有 URL 判断、Method 判断、参数处理、逻辑代码处理等,都需要我们自己来处理和封装。

  • 内容混乱

    所有的内容都放在一起,会非常的混乱。

Web 应用开发框架

目前在 Node 中比较流行的 Web 服务器框架是 expresskoa

我们先来学习 express,后面再学习 koa,并且对他们进行对比;

Express:是基于 Node.js 平台的一款轻量级、灵活且功能强大的 Web 应用开发框架。它简化了 Node.js 原生 HTTP 模块的开发流程,通过提供路由中间件请求 / 响应处理等核心功能,帮助开发者快速构建 Web 应用、API 接口或后端服务。

安装

Express 的安装方式

  • 方式一:express-generator 脚手架安装
  • 方式二:从零搭建

脚手架安装

方式一:express-generator 脚手架安装

依赖安装

  • express-generator:用于快速生成 Express 项目的脚手架工具。

    sh
    npm i express-generator -g # 全局安装

基本使用

以创建名为 my-express-app 的项目为例,步骤如下:

  1. 创建项目:执行以下命令,生成项目结构:

    sh
    express my-express-app # 1. 创建项目

    可选参数

    • --view 视图引擎默认:pug,指定模版引擎(如 ejs、pug、hbs)。
    • -e:快速指定 ejs 模板引擎(等同于 --view=ejs)。
    • --git:自动生成 .gitignore 文件。
  2. 项目目录结构

    sh
    ├── app.js
    ├── bin
       └── www
    ├── package-lock.json
    ├── package.json
    ├── public
       ├── images
       ├── javascripts
       └── stylesheets
           └── style.css
    ├── routes
       ├── index.js
       └── users.js
    └── views
        ├── error.jade
        ├── index.jade
        └── layout.jade
  3. 安装项目依赖并启动项目

    sh
    npm i # 1. 安装项目依赖
    npm start # 启动项目
  4. 访问项目

    打开浏览器访问 http://localhost:3000,若看到 "Welcome to Express" 页面,则项目启动成功。

从零搭建

方式二:从零搭建

刚才创建的项目 express 项目,很多内容可能我们并不认识,所以刚开始我们最好从零来学习。

  1. 初始化新项目

    sh
    npm init -y
  2. 安装 express

    需先安装 Node.js(建议 v14+),然后通过 npm 安装 express:

    sh
    npm i express
  3. 创建 Express 应用:见基本示例:

基本示例

我们来创建自己的第一个 express 程序:

js
const express = require('express')

// 创建服务器
const app = express()

// /home的get请求处理
app.get('/home', (req, res) => {
  res.end('Hello Home')
})

// /login的post请求处理
app.post('/login', (req, res) => {
  res.end('Hello Login')
})

// 开启监听
app.listen(8000, () => {
  console.log('服务器启动成功~')
})

后续操作:分离请求

我们会发现,之后的开发过程中,可以方便的将请求进行分离。无论是不同的 url,还是 get、post 等请求方式。这样的方式非常方便维护、扩展

中间件

认识中间件

Express 是一个路由中间件的 Web 框架,它本身的功能非常少:Express 应用程序本质上是一系列中间件函数的调用

中间件(middleware):是一类处理请求的函数,能够访问请求对象(req)、响应对象(res)以及下一个中间件函数(next)。本质是传递给 express 的一个回调函数

基本语法

  1. 普通中间件(3个参数)

    js
    function middleware(req, res, next) {
      // 1. 执行逻辑(如日志、验证、修改req/res等)
        
      // 2.1 调用 next() 传递控制权给下一个中间件/路由
      next(); 
        
      // 2.2 或调用 res.send() 等方法终止请求循环
      // res.send()
    }
  2. 错误处理中间件(4个参数)

    js
    function errorMiddleware(err, req, res, next) {
      // 处理错误(如打印日志、返回错误响应)
      console.error(err.stack);
      res.status(500).send('服务器出错了!');
    }

参数解析

  • reqRequest请求对象。包含客户端信息,如参数、headers 等。
  • resResponse响应对象。用于向客户端发送数据。
  • next(err?)=>void回调函数,调用后会将控制权传递给下一个中间件或路由处理函数;若不传,请求会被挂起(客户端一直等待响应)。
  • err?Error,抛出的错误。

image-20251023175136105

中间件的作用

  • 执行逻辑(如日志记录、权限校验)
  • 修改请求(request)和响应(response)对象
  • 结束请求-响应周期(返回数据)
  • 调用栈中的下一个中间件

示例:中间件的作用

image-20251021174912146

执行下一个中间件

如果当前中间件功能没有结束请求-响应周期,则必须调用 next() 将控制权传递给下一个中间件或路由处理函数;若不传,请求会被挂起(客户端一直等待响应)。

image-20251021175024359

注册中间件

中间件注册方式

那么,如何将一个中间件注册到我们的应用程序中呢?express 主要提供了两种方式:

  • app/router.use()
  • app/router.[methods]()

注意

  • 可以是 app,也可以是 router,router 我们后续再学习。
  • methods 指的是常用的请求方式,如 get、post、put 等。

我们先来学习 use 的用法,因为 methods 的方式本质是 use 的特殊情况;

普通中间件

语法

js
// 通过 .use() 注册,不区分 path 和 methods
app.use((req, res, next?) => {})

// 或者
router.use((req, res, next?) => {})

之所以称之为最普通的中间件,是因为无论是什么 path、methods 都会匹配该中间件;

js
const express = require('express')

const app = express()

app.use((req, res, next) => {
  console.log('common middleware 01')
  next() // 执行下一个中间件
})

app.use((req, res, next) => {
  console.log('common middleware 02')
  res.end('Hello Common Middleware~') // 返回结果,结束请求-响应周期
})

app.listen(8000, () => {
  console.log('中间件服务器启动成功~')
})

中间件的执行顺序:在匹配上的情况下,中间件按照注册的顺序执行

path 匹配中间件

通过 app.use(path, middleware) 为中间件指定路径前缀,仅当请求路径匹配该 path 前缀时才执行:

js
// 仅对 /home 开头的请求生效(如 /home/users、/home/posts)
app.use('/home', (req, res, next) => {
  console.log('home middleware 01')
  next()
})

app.use('/home', (req, res, next) => {
  console.log('home middleware 02')
  next()
  res.end('Hello Home middleware')
})

app.use((req, res, next) => {
  console.log('common middleware')
})

path 和 method 匹配中间件

当请求路径同时匹配 path 和 method 时执行该中间件:

js
// 案例三: method匹配中间件
app.get('/home', (req, res, next) => {
  console.log('home get middleware')
  next()
})

app.post('/login', (req, res, next) => {
  console.log('login post middleware')
  next()
})

app.use((req, res, next) => {
  console.log('common middleware')
})

注册多个中间件

一个路径可绑定多个中间件,按定义顺序执行,通过 next() 传递控制权:

js
// 案例四: 注册多个中间件
const homeMiddleware1 = (req, res, next) => {
  console.log('home middleware 01')
  next()
}

const homeMiddleware2 = (req, res, next) => {
  console.log('home middleware 02')
  next()
}

const homeHandle = (req, res, next) => {
  res.end('Hello Home~')
}

app.get('/home', homeMiddleware1, homeMiddleware2, homeHandle)

中间件执行流程

中间件执行流程

Express 中间件按定义顺序执行,整个请求 - 响应流程如下:

  1. 客户端发送请求到服务器。
  2. 请求进入第一个中间件,执行逻辑后调用 next() 传递给下一个中间件。
  3. 依次经过后续中间件,直到匹配到路由处理函数。
  4. 路由处理函数执行并通过 res.send() 等方法发送响应,终止请求循环。
  5. 若过程中抛出错误(同步 throw 或异步 next(err)),则跳过后续中间件,直接执行错误处理中间件。

示例:执行顺序演示

js
const express = require('express');
const app = express();

// 中间件1
app.use((req, res, next) => {
  console.log('中间件1 执行');
  next();
});

// 中间件2
app.use((req, res, next) => {
  console.log('中间件2 执行');
  next(); // 传递给路由
});

// 路由处理函数
app.get('/', (req, res) => {
  console.log('路由处理函数执行');
  res.send('响应客户端'); // 终止请求循环
});

// 中间件3(路由已终止请求,不会执行)
app.use((req, res, next) => {
  console.log('中间件3 执行'); // 不会被打印
  next();
});

// 错误处理中间件(无错误时不执行)
app.use((err, req, res, next) => {
  console.log('错误处理中间件执行');
});

// 输出顺序:中间件1 执行 → 中间件2 执行 → 路由处理函数执行

内置中间件

并非所有的中间件都需要我们从零去编写:

  • express 有内置一些帮助我们完成对 request 解析的中间件;
  • registry 仓库中也有很多可以辅助我们开发的中间件;

在客户端发送 post 请求时,会将数据放到 body 中:

  • 客户端可以通过 json 的方式传递;
  • 也可以通过 form 表单的方式传递;

express 内置了解析以上2种参数类型的中间件

解析 json 参数中间件

  1. 前端发送用户登录请求(携带 json 参数)

    我们这里先使用 json 传递给服务器 body:

    image-20240719155926918

  2. 后端解析

    1. 方式一:手动解析

      不进行解析时的操作:

      js
      app.post('/login', (req, res, next) => {
        req.on('data', (data) => {
          console.log(data.toString())
        })
        req.on('end', () => {
          res.end('登录成功~')
        })
      })

      我们也可以自己编写中间件来解析 JSON:

      js
      // 解析 json 的普通中间件
      app.use((req, res, next) => {
        if (req.headers['content-type'] === 'application/json') {
          req.on('data', (data) => {
            const userInfo = JSON.parse(data.toString())
            req.body = userInfo
          })
          req.on('end', () => {
            next()
          })
        } else {
          next()
        }
      })
      
      app.post('/login', (req, res, next) => {
        console.log(req.body)
        res.end('登录成功~')
      })
    2. 方式二:body-parser 中间件解析

      事实上我们可以使用 expres 内置的中间件或者使用body-parser来完成:

      js
      app.use(express.json())
      
      app.post('/login', (req, res, next) => {
        console.log(req.body)
        res.end('登录成功~')
      })

解析 form 参数中间件

  1. 前端发送用户登录请求(携带 form 参数)

    如果我们解析的是 application/x-www-form-urlencoded

    image-20240719155943157

  2. 后端解析: urlencoded 中间件解析

    后端可以使用 express 自带的 expresss.urlencoded() 函数来作为中间件:

    • 传入的 extended 用于表示使用哪一种解析方式:
      • true:使用 qs 第三方模块;
      • false:使用 querystring 内置模块;
      • 备注:它们之间的区别这里不展开讲解;
    js
    app.use(express.json())
    app.use(express.urlencoded({ extended: true }))
    
    app.post('/login', (req, res, next) => {
      console.log(req.body)
      res.end('登录成功~')
    })

第三方中间件

morgan 请求日志记录

API-morgan

General

  • morgan()(format, options?),用于创建一个 HTTP 请求日志中间件,并根据传入的参数配置日志格式、输出方式等行为。
介绍 morgan

如果我们希望将请求日志记录下来,那么可以使用 express 官网开发的第三方库:morgan

依赖安装

  • morgan:记录请求日志到指定文件中。

    sh
    npm i morgon

基本示例

1、直接作为中间件使用即可:

js
const loggerWriter = fs.createWriteStream('./log/access.log', { flags: 'a+' })
app.use(morgan('combined', { stream: loggerWriter }))

2、日志内容:

image-20251022174257370

multer 文件上传@

文件上传我们可以使用 express 官方开发的第三方库:multer

依赖安装

  • multer:解析上传的文件。

    sh
    npm i multer
单文件上传

单文件上传

  1. 前端上传文件

    image-20251022175039746

  2. 后端使用 upload.single() 解析上传的文件

    1. 只指定文件存储路径

      通过 multer({dest}) 指定文件存储路径,并且随机文件名:

      js
      // 1. 调用 multer() 方法,创建 upload 对象
      const upload = multer({
        dest: 'uploads/'
      })
      
      // 2. 在路由中应用 upload.single() 中间件,解析上传的单个文件
      app.post('/upload', upload.single('avatar'), (req, res, next) => {
          
        // 3. 通过 req.file 获取上传的文件
        console.log(req.file.buffer)
        res.end('文件上传成功~')
      })
    2. 自定义文件存储路径和文件名

      通过 multer.diskStorage({destination, filename}) 支持自定义文件名、存储路径:

      js
      const storage = multer.diskStorage({
        // 1. 自定义存储目录
        destination: (req, file, cb) => {
          cb(null, 'uploads/')
        },
        // 2. 自定义文件名
        filename: (req, file, cb) => {
          cb(null, Date.now() + path.extname(file.originalname))
        }
      })
      
      const upload = multer({
        // 3. 使用自定义 storage
        storage: storage
      })
      
      app.post('/upload', upload.single('avatar'), (req, res, next) => {
        console.log(req.file.buffer)
        res.end('文件上传成功~')
      })
    3. req.file 对象:

      image-20251023104714081

多文件上传(同一字段)

多文件上传(同一字段)

  1. 前端上传多文件(同一字段)

    image-20251023113932845

  2. 后端使用 upload.array() 解析上传的文件

    1. 只解析文件:使用 upload.array() 处理上传多张图片

      js
      app.use('/upload', upload.array('photos'), (req, res, next) => {
        console.log(req.files)
      })
    2. req.files 对象:

      image-20251023114154125

form-data 解析

form-data 解析

  1. 前端发送 form-data 数据

    不推荐通过 form-data 发送数据,推荐 json

    image-20240719160003319

  2. 后端使用 upload.any() 解析

    注意

    • 通过 form-data 发送的数据,在后端无法使用 express.json()express.urlencoded() 解析

    • 使用 upload.any() 解析文件的同时,还可以解析一些 form-data 中的普通数据。

    • 使用 upload.any() 解析文件后,可以通过 req.files 获取文件信息(数组形式)。

    • 不需要预先定义要处理的字段名,因此适用于字段名动态变化的场景。

    js
    // 1. 返回 upload 对象
    const upload = multer()
    
    // 2. 通过 upload.any() 返回解析 form-data 数据的中间件
    app.use('/login', upload.any(), (req, res, next) => {
    
      // 3. 通过 req.body 获取解析后的 form-data 数据
      console.log(req.body)
        
      // 4. 通过 req.files 获取上传的文件信息 
    })
  3. req.body 对象:

    image-20251023115718349

  4. req.files 数组:

    image-20251024162710632

请求/响应

请求参数解析

请求参数类型:客户端传递到服务器参数的方法常见的是 5 种:

  • 方式一:通过 get 请求中的 URL 的 params;
  • 方式二:通过 get 请求中的 URL 的 query;
  • 方式三:通过 post 请求中的 body 的 json 格式(中间件中已经使用过);
  • 方式四:通过 post 请求中的 body 的 x-www-form-urlencoded 格式(中间件使用过);
  • 方式五:通过 post 请求中的 form-data 格式(中间件中使用过);

GET 发送 params

方式一:GET 发送 params

请求地址http://localhost:8000/login/abc/why

获取参数req.params


示例:解析 GET 发送 params

  1. 前端发送请求

    image-20251023140617017

  2. 后端解析:通过 req.params 解析。

    js
    app.use('/users/:id/:name', (req, res, next) => {
      console.log(req.params)
      res.json('请求成功~')
    })

GET 发送 query

方式二:GET 发送 query

请求地址http://localhost:8000/login?username=why&password=123

获取参数req.query


示例:解析 GET 发送 query

  1. 前端发送请求

    image-20251023121908657

  2. 后端解析:通过 req.query 解析。

    image-20251023122114903

POST 发送 JSON

POST 发送 x-www-form-urlencoded

POST 发送 FormData

响应方法

res.end()

res.end()(),用于结束当前响应流程,告知客户端响应已完成并发送最后的数据。

  • res.end()(),无参数,仅结束响应。

  • res.end()(data),发送数据后结束响应。

  • res.end()(data, encoding),指定编码格式发送数据后结束响应。

    • data?string|buffer,要发送给客户端的最后数据。

    • encoding?string默认:utf8,当 data 为字符串时,指定其编码格式(如 'utf8''base64' 等)。若 data 为 Buffer,此参数会被忽略。

  • 返回:

  • resResponse,返回当前的响应对象(res 本身),支持链式调用(不常用)。

示例

  1. 无参数:仅结束响应(客户端收到空响应)

    js
    if (req.url === '/empty') {
        res.end(); 
        return;
    }
  2. 带字符串数据:发送文本后结束响应

    js
    if (req.url === '/text') {
        res.end('Hello, World!'); // 等价于 res.end('Hello, World!', 'utf8')
        return;
    }
  3. 带 Buffer 和编码:发送二进制数据后结束响应

    js
    if (req.url === '/buffer') {
        const buffer = Buffer.from('Binary data', 'utf8');
        res.end(buffer); // encoding 被忽略(因 data 是 Buffer)
        return;
    }

核心特性

  1. 必须调用 end() 终止响应

    无论是否发送数据,都必须调用 res.end() 来终止响应,否则客户端会一直等待(超时后报错)。

  2. 数据大小限制

    res.end() 适合发送少量 “收尾数据”,若需发送大量数据,应使用 res.write() 分块发送,最后用 res.end() 结束。

  3. 调用时机

    res.end() 是响应的 “最后一步”,调用后不能再对响应进行任何操作,否则会抛出错误。

res.json()

res.json()(data),用于向客户端发送JSON 格式的响应

  • dataobject|array|string|...支持多种类型,要发送给客户端的最后数据。

  • 返回:

  • resResponse,返回当前的响应对象(res 本身),支持链式调用。

示例

  1. 链式调用(设置状态码后发送 JSON)

    js
    app.get('/error', (req, res) => {
      // 先设置 404 状态码,再发送错误信息 JSON
      res.status(404).json({ code: 404, message: '资源不存在' });
    });

核心特性

  1. data 参数支持的数据类型

    data 支持多种数据类型,Express 会自动将其序列化为 JSON 字符串:

    参数类型说明序列化规则示例
    Object普通对象(键值对){ name: '张三', age: 20 }{"name":"张三","age":20}
    Array数组[1, 2, 'a'][1,2,"a"]
    String字符串"hello""hello"(JSON 字符串格式,带双引号)
    Number数字(整数、浮点数、NaN、Infinity)4242NaNnullInfinitynull(JSON 规范不支持 NaN/Infinity)
    Boolean布尔值truetruefalsefalse
    null空值nullnull
    Date日期对象new Date('2023-01-01')"2023-01-01T00:00:00.000Z"(ISO 字符串)
    Buffer二进制缓冲区对象转为 base64 字符串,如 Buffer.from('test')"dGVzdA=="
    其他特殊对象MapSet 等(需实现 toJSON() 方法,否则默认序列化结果可能不符合预期)new Map([['a', 1]]){}(默认无 toJSON() 方法,需手动处理
    js
    const express = require('express');
    const app = express();
    
    // 1. 发送对象
    app.get('/user', (req, res) => {
      res.json({ name: '张三', age: 20, isStudent: false });
    });
    
    // 2. 发送数组
    app.get('/list', (req, res) => {
      res.json(['苹果', '香蕉', '橙子']);
    });
    
    // 3. 发送特殊值
    app.get('/special', (req, res) => {
      res.json({
        date: new Date('2023-01-01'), // "2023-01-01T00:00:00.000Z"
        buffer: Buffer.from('hello'), // "aGVsbG8="
        nan: NaN, // null
        infinity: Infinity, // null
        nullVal: null // null
      });
    });
    
    app.listen(3000);
  2. 对比 res.send()

    res.send() 也可以发送 JSON 数据(当传入对象 / 数组时),但 res.json() 更明确且有以下差异:

    • Content-Type 优先级

      • res.json()强制将响应头 Content-Type 设置为 application/json,即使之前通过 res.set() 手动设置了其他类型。

      • res.send() 会根据数据类型自动推断(如对象 / 数组设为 application/json,字符串设为 text/html)。

    • 对非对象类型的处理

      • res.json('hello') 会发送 JSON 字符串(带引号:"hello")。
      • res.send('hello') 会发送纯文本(不带引号:hello)。
    js
    // res.json() 发送字符串 → JSON 格式
    app.get('/json-str', (req, res) => {
      res.json('hello'); // 响应:"hello"(Content-Type: application/json)
    });
    
    // res.send() 发送字符串 → 纯文本格式
    app.get('/send-str', (req, res) => {
      res.send('hello'); // 响应:hello(Content-Type: text/html; charset=utf-8)
    });
  3. JSON 序列化限制

    res.json() 内部使用 JSON.stringify() 进行序列化,因此受限于 JSON 规范

    • 不支持循环引用(如 const a = {}; a.self = a; res.json(a) 会抛出错误)。
    • 不支持 FunctionSymbol 类型(会被忽略或转为 null)。
    • NaNInfinity 会被转为 null(如示例中所示)。

res.status()

res.status()(code),用于设置 HTTP 响应的状态码。常与 res.send()res.json() 等方法配合使用。

  • codenumber,表示 HTTP 响应状态码。其值必须符合 HTTP 协议规范,有效范围为 100-599

  • 返回:

  • resResponse,返回当前的响应对象(res 本身),支持链式调用。

示例

  1. 成功响应(200/201)

    js
    // 200(OK):请求成功
    app.get('/success', (req, res) => {
      res.status(200).send('请求成功'); // 链式调用:设置状态码后发送文本
    });
    
    // 201(Created):资源创建成功
    app.post('/user', (req, res) => {
      res.status(201).json({ message: '用户创建成功', id: 123 }); // 发送 JSON 响应
    });
  2. 客户端错误(400/404)

    js
    // 400(Bad Request):请求参数错误
    app.post('/login', (req, res) => {
      if (!req.body.username) {
        res.status(400).json({ error: '用户名不能为空' });
      }
    });
    
    // 404(Not Found):资源不存在
    app.get('/nonexistent', (req, res) => {
      res.status(404).send('<h1>页面不存在</h1>'); // 发送 HTML 响应
    });
  3. 服务器错误(500)

    js
    // 500(Internal Server Error):服务器内部错误
    app.get('/error', (req, res) => {
      try {
        // 模拟错误
        throw new Error('数据库连接失败');
      } catch (err) {
        res.status(500).json({ error: '服务器内部错误', details: err.message });
      }
    });
  4. 重定向(301/302)

    js
    // 301(Moved Permanently):永久重定向
    app.get('/old-path', (req, res) => {
      res.status(301).redirect('/new-path'); // 结合 redirect() 方法
    });
    
    // 302(Found):临时重定向(Express 中 redirect() 默认使用 302)
    app.get('/temp', (req, res) => {
      res.status(302).redirect('/temp-new');
    });

核心特性

  1. 对比 res.sendStatus()

    • res.status(code):仅设置状态码,不发送响应内容,需配合 send()json() 等方法发送数据;
    • res.sendStatus(code):设置状态码的同时,自动发送该状态码对应的默认描述文本(如 res.sendStatus(404) 等价于 res.status(404).send('Not Found'))。
    js
    // res.status() + send():自定义响应内容
    res.status(404).send('自定义:页面不见了'); 
    
    // res.sendStatus():使用默认描述
    res.sendStatus(404); // 响应体为 'Not Found'(默认文本)
  2. 状态码的默认值

    若未通过 res.status() 显式设置状态码,Express 会根据响应类型自动使用默认值

    • 成功响应(如 res.send('ok'))默认状态码为 200
    • 重定向(res.redirect())默认状态码为 302
    • 错误响应(如未捕获的异常)可能默认使用 500

路由

介绍路由

路由(Routing):用于定义客户端请求的 URL 路径服务器处理逻辑 之间的映射关系。

基本语法

js
// app 级路由
app.METHOD(PATH, HANDLER)

// router 级路由
router.METHOD(PATH, HANDLER)
  • appExpress 应用实例(通过 express() 创建)。
  • routerRouter 实例(通过 express.Router() 创建)。
  • METHODHTTP 请求方法(如 getpostputdelete 等),需用小写形式。
  • PATH:客户端请求的 URL 路径(可以是字符串、字符串模式或正则表达式)。
  • HANDLER:当路由匹配时执行的处理函数(中间件),格式为 (req, res, next) => {},其中:
    • req:请求对象(包含客户端发送的信息,如参数、 headers 等)。
    • res:响应对象(用于向客户端返回数据)。
    • next:用于调用下一个中间件或路由处理函数的回调函数。

Router 路由

如果我们将所有的代码逻辑都写在 app 中,那么 app 会变得越来越复杂:

  • 一方面完整的 Web 服务器包含非常多的处理逻辑;

  • 另一方面有些处理逻辑其实是一个整体,我们应该将它们放在一起,比如对 users 相关的处理:

    • 获取用户列表
    • 获取某一个用户信息
    • 创建一个新的用户
    • 删除一个用户
    • 更新一个用户

我们可以使用 express.Router() 来创建一个路由处理程序:

  • 一个 Router 实例拥有完整的中间件和路由系统;
  • 因此,它也被称为 迷你应用程序(mini-app)

路由的基本使用

js
// 1. 创建路由对象
const userRouter = express.Router()

// 2. 路由接口映射
// GET 路由(获取资源)
userRouter.get('/', (req, res, next) => {
  res.end('用户列表')
})

// POST 路由(创建资源)
userRouter.post('/', (req, res, next) => {
  res.end('创建用户')
})

// UT 路由(更新资源)
userRouter.put('/:id', (req, res) => {
  const userId = req.params.id; // 获取动态参数 id
  const updatedData = req.body; // 获取更新的数据
  res.json({ message: `用户 ${userId} 全量更新成功`, data: updatedData });
});

// DELETE 路由(删除资源)
userRouter.delete('/:id', (req, res, next) => {
  res.end('删除用户', req.params.id)
})

// 3. 挂载路由
app.use('/users', userRouter)

动态路由

当需要匹配动态变化的路径(如 /users/123/posts/456)时,可使用路由参数(以 :参数名 定义)。路由参数的值会被存储在 req.params 对象中。

基本用法

js
// 路径为 "/users/:id",:id 是动态参数
app.get('/users/:id', (req, res) => {
    
  // req.params 是一个对象,键为参数名,值为客户端传入的参数
  console.log(req.params); // 若访问 /users/123,输出 { id: '123' }
    
  res.send(`访问的用户 ID 是:${req.params.id}`);
});

多个参数

路由可以包含多个动态参数,参数名需唯一:

js
// 路径为 "/users/:userId/posts/:postId"
app.get('/users/:userId/posts/:postId', (req, res) => {
    
  const { userId, postId } = req.params;
  res.send(`用户 ${userId} 的文章 ${postId}`);
});

// 访问 /users/1/posts/100 → 输出 "用户 1 的文章 100"

可选参数

通过在参数名后加 ?,可将参数设为可选(即该参数可传可不传):

js
// 路径为 "/search/:keyword?",:keyword 是可选参数
app.get('/search/:keyword?', (req, res) => {
    
  const keyword = req.params.keyword || '默认关键词';
    
  res.send(`搜索关键词:${keyword}`);
});

// 访问 /search → 输出 "搜索关键词:默认关键词"
// 访问 /search/express → 输出 "搜索关键词:express"

正则表达式约束参数格式

express@5 版本变化:

从 Express 5.0 开始,框架移除了对在路径字符串中直接使用这类正则元字符(包括 *?+等)的支持。

解决方案:直接使用 RegExp 对象,如 app.get(/^\/users\/:id$/),但是不能直接在路径部分约束 :id 的类型了,只能在其他地方限制,如 //.test(req.params.id)

通过正则表达式,可以限制参数的格式(如仅允许数字、特定长度等):

js
// 仅匹配参数为数字的路径(\d+ 表示一个或多个数字)
app.get('/users/:id(\\d+)', (req, res) => {
  res.send(`用户 ID(数字):${req.params.id}`);
});

// 访问 /users/123 → 匹配成功
// 访问 /users/abc → 匹配失败(会交给后续路由或返回 404)

路由封装

路由封装到单独文件

  1. userRouter.js 中封装路由逻辑

    image-20251023151955077

  2. app.js 中导入并挂载 userRouter

    image-20251023152303215

静态资源服务器

部署静态资源

部署静态资源我们可以选择很多方式:

Node 也可以作为静态资源服务器,并且 express 给我们提供了方便部署静态资源的方法。


express 部署打包后的项目为静态资源

image-20251023153617435

js
const express = require('express')

const app = express()

// build 目录存放的是打包后的项目
// 内置的中间件:直接将一个目录作为静态资源
app.use(express.static('./build'))

app.listen(8000, () => {
  console.log('静态服务器启动成功~')
})

错误处理

Express 的错误处理主要依赖错误处理中间件,并针对同步代码、异步代码(回调、Promise、async/await)提供了不同的错误捕获方式。

错误处理中间件

Express 中,错误处理的核心是错误处理中间件。与普通中间件不同,它必须接收4 个参数err, req, res, next),即使不需要使用 next,也必须声明该参数,否则 Express 会将其视为普通中间件,无法捕获错误。

基本语法

js
app.use((err, req, res, next) => {
  // 1. 处理错误(如打印日志、记录到数据库等)
  console.error('错误信息:', err.message);
    
  // 2. 向客户端返回错误响应
  res.status(err.statusCode || 500)
     .json({error: err.message || '服务器内部错误'});
});

错误响应的方案

  1. 通过 status() 返回错误对应的 HTTP 状态码

    js
    res.status(err.statusCode || 500).json({error: err.message || '服务器内部错误'});
  2. 状态码始终返回200,通过 json() 返回自定义的错误类型

    js
    res.json({
        code: 1001,
        message: '自定义的错误'
    });

关键特性

  1. 集中式处理

    所有错误最终都会流转到错误处理中间件,避免在每个路由中重复编写错误处理逻辑。

  2. 位置要求

    必须放在所有路由和普通中间件之后,否则无法捕获后续代码抛出的错误。

  3. 错误传递

    错误通过 next(err) 方法传递给错误处理中间件(同步错误和async/await错误会被 Express 自动捕获并传递)。

错误捕获

Express 中错误分为同步错误异步错误,两者的捕获方式不同。

同步错误捕获

同步代码中(如直接执行的逻辑、无回调的代码),通过 throw 抛出的错误会被 Express 自动捕获,并传递给错误处理中间件。

js
// 同步路由逻辑
app.get('/sync-error', (req, res) => {
  // 1. 同步错误:直接 throw
  if (!req.query.id) {
    throw new Error('缺少 id 参数'); // Express 自动捕获并传递给错误处理中间件
  }
  res.send('成功');
});

// 2. 错误处理中间件(放在最后)
app.use((err, req, res, next) => {
  res.status(400).send({ error: err.message }); // 响应:{ error: '缺少 id 参数' }
});

异步错误捕获

异步代码(如回调函数、Promise、定时器、数据库操作等)中,错误不会被 Express 自动捕获,必须手动通过 next(err) 传递给错误处理中间件。

回调函数中的错误

对于基于回调的异步操作(如 fs.readFile),需在回调中捕获错误并调用 next(err)

js
const fs = require('fs');

app.get('/read-file', (req, res, next) => {
  // 异步操作:读取文件(回调形式)
  fs.readFile('nonexistent.txt', (err, data) => {
    if (err) {
      // 1. 手动传递错误给错误处理中间件
      return next(err); // 2. 关键:必须用 return 终止后续逻辑
    }
    res.send(data.toString());
  });
});

// 3. 错误处理中间件
app.use((err, req, res, next) => {
  console.error('文件读取错误:', err.message);
  res.status(500).send({ error: '文件读取失败' });
});
Promise 中的错误

对于返回 Promise 的异步操作(如 fetch、Mongoose 数据库操作),需通过 .catch() 捕获错误并传递给 next

js
// 模拟一个返回 Promise 的异步操作
const fetchData = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // 1. 出现异步错误:Promise reject
      reject(new Error('数据获取失败'));
    }, 1000);
  });
};

app.get('/promise-error', (req, res, next) => {
  fetchData()
    .then(data => res.send(data))
    .catch(err => {
      next(err); // 2. 捕获 reject 错误,传递给错误处理中间件
    });
});
async/await 中的错误

async/await 是 Promise 的语法糖,异步错误需通过 try/catch 捕获,再用 next(err) 传递

js
app.get('/async-await-error', async (req, res, next) => {
  try {
    // 模拟异步操作(await 后面跟 Promise)
    const data = await fetchData(); // fetchData 是前面定义的 Promise 函数
    res.send(data);
  } catch (err) {
    // 捕获 await 抛出的错误(即 Promise reject)
    next(err); // 传递给错误处理中间件
  }
});

简化写法

由于 async 函数返回的 Promise 若 reject,Express 会自动将错误传递给错误处理中间件,因此可省略 try/catch,直接让错误 “冒泡”

js
app.get('/async-await-error', async (req, res) => {
  // 若 fetchData  reject,async 函数会返回 rejected Promise,Express 自动捕获
  const data = await fetchData(); 
  res.send(data);
});

源码

express()

1、创建 app

image-20240206173619910

2、express()函数的本质其实是createApplication(),返回一个 app 函数对象

image-20240206174820129

app.listen()

1、调用app.listen()

image-20240206175005205

2、在createApplication()中通过mixin()将 app 进行了混入

image-20240206175403640

3、app.listen()本质上是对http.createServer(this)的封装,此处的 this 指向 app

image-20240206175054809

app.use()

1、注册中间件

1、通过 use 来注册一个中间件

js
// 注册普通中间件
+++ app.use(
  (req, res, next) => {
    console.log('普通中间件1')
    next()
  },
  (req, res, next) => {
    console.log('普通中间件2')
    next()
  }
)

2、无论是 app.use 还是 app.methods 都会注册一个主路由, app 本质上会将所有的函数,交给这个主路由去处理

js
// application.js

// 2. 实现use()
app.use = function use(fn) {
  // 初始化变量
  var offset = 0;
  var path = '/';

  // default path to '/'
  // disambiguate app.use([fn])
  // 参数fn可以是function也可以是path + function
  if (typeof fn !== 'function') {
    var arg = fn;

    // 取出参数列表中的第一个参数,此时它是path
    while (Array.isArray(arg) && arg.length !== 0) {
      arg = arg[0];
    }

    // first arg is the path
    // 获取到path
    if (typeof arg !== 'function') {
      offset = 1;
      path = fn;
    }
  }

  var fns = flatten(slice.call(arguments, offset));

  if (fns.length === 0) {
    throw new TypeError('app.use() requires a middleware function')
  }

  // setup router
  // 路由器懒加载
  this.lazyrouter();
  var router = this._router;

  // 遍历中间件函数
  fns.forEach(function (fn) {
    // non-express app
    // 非Express应用中间件,直接使用router.use(path, fn)将其注册到指定的路径
    if (!fn || !fn.handle || !fn.set) {
      return router.use(path, fn);
    }

    debug('.use app under %s', path);
    fn.mountpath = path;
    fn.parent = this;

    // restore .app property on req and res
    // 创建一个新的中间件函数
    router.use(path, function mounted_app(req, res, next) {
      var orig = req.app;
      fn.handle(req, res, function (err) {
        setPrototypeOf(req, orig.request)
        setPrototypeOf(res, orig.response)
        next(err);
      });
    });

    // mounted an app
    fn.emit('mount', this);
  }, this);

  return this;
};

3、在主路由router.use(path, fn)中,一个函数 fn 会创建一个 layer,并被放入到 router.stack

js
proto.use = function use(fn) {
  var offset = 0;
  var path = '/';

  // default path to '/'
  // disambiguate router.use([fn])
  if (typeof fn !== 'function') {
    var arg = fn;

    while (Array.isArray(arg) && arg.length !== 0) {
      arg = arg[0];
    }

    // first arg is the path
    if (typeof arg !== 'function') {
      offset = 1;
      path = fn;
    }
  }

  var callbacks = flatten(slice.call(arguments, offset));

  if (callbacks.length === 0) {
    throw new TypeError('Router.use() requires a middleware function')
  }

  for (var i = 0; i < callbacks.length; i++) {
    var fn = callbacks[i];

    if (typeof fn !== 'function') {
      throw new TypeError('Router.use() requires a middleware function but got a ' + gettype(fn))
    }

    // add the middleware
    debug('use %o %s', path, fn.name || '<anonymous>')

+++    var layer = new Layer(path, {
      sensitive: this.caseSensitive,
      strict: false,
      end: false
    }, fn);

    layer.route = undefined;
	// this指向router,所以fns也是保存在router.stack中
+    this.stack.push(layer);
  }

  return this;
};

4、在 Layer 中,会将 fn 赋值给layer.handle

js
function Layer(path, options, fn) {
  if (!(this instanceof Layer)) {
    return new Layer(path, options, fn);
  }

  debug('new %o', path)
  var opts = options || {};

+  this.handle = fn;
  this.name = fn.name || '<anonymous>';
  this.params = undefined;
  this.path = undefined;
  this.regexp = pathRegexp(path, this.keys = [], opts);

  // set fast path flags
  this.regexp.fast_star = path === '*'
  this.regexp.fast_slash = path === '/' && opts.end === false
}

2、请求的处理过程

如果有一个请求过来,那么从哪里开始呢?

1、当请求过来时,会被app.listen监听并执行http.createServer(this)中的 this(app)

js
// 1、调用 app.listen
+++ app.listen(8000, () => {
  console.log('express is running...')
})
js
// 2、调用 app.listen 的时候,本质上是调用 proto 中的 listen
app.listen = function listen() {
+++  var server = http.createServer(this);
  return server.listen.apply(server, arguments);
};

2、app 函数被调用开始的;

js
function createApplication() {
  // 2.1 定义app变量,给变量赋值为一个中间件函数
  var app = function(req, res, next) {
+++    app.handle(req, res, next);
  };
  // 省略
}

3、app.handle 本质上会去调用 router.handle

js
app.handle = function handle(req, res, callback) {
  var router = this._router;

  // final handler
  var done = callback || finalhandler(req, res, {
    env: this.get('env'),
    onerror: logerror.bind(this)
  });

  // no routes
  if (!router) {
    debug('no routes defined on app');
    done();
    return;
  }

+++  router.handle(req, res, done);
};

4、router.handle 中做的事:

  • 取出 fns(layer):var stack = self.stack
  • 执行 next:next()
    • 遍历 fns(layer),匹配 path
    • 当匹配到时,执行 fn(layer)
js
proto.handle = function handle(req, res, out) {
  var self = this;

  debug('dispatching %s %s', req.method, req.url);

  var idx = 0;
  var protohost = getProtohost(req.url) || ''
  var removed = '';
  var slashAdded = false;
  var sync = 0
  var paramcalled = {};

  // store options for OPTIONS request
  // only used if OPTIONS request
  var options = [];

  // middleware and routes
++  var stack = self.stack;

  // manage inter-router variables
  var parentParams = req.params;
  var parentUrl = req.baseUrl || '';
  var done = restore(out, req, 'baseUrl', 'next', 'params');

  // setup next layer
  req.next = next;

  // for options requests, respond with a default if nothing else responds
  if (req.method === 'OPTIONS') {
    done = wrap(done, function(old, err) {
      if (err || options.length === 0) return old(err);
      sendOptionsResponse(res, options, old);
    });
  }

  // setup basic req values
  req.baseUrl = parentUrl;
  req.originalUrl = req.originalUrl || req.url;

++  next();

  function next(err) {
    var layerError = err === 'route'
      ? null
      : err;

    // remove added slash
    if (slashAdded) {
      req.url = req.url.slice(1)
      slashAdded = false;
    }

    // restore altered req.url
    if (removed.length !== 0) {
      req.baseUrl = parentUrl;
      req.url = protohost + removed + req.url.slice(protohost.length)
      removed = '';
    }

    // signal to exit router
    if (layerError === 'router') {
      setImmediate(done, null)
      return
    }

    // no more matching layers
    if (idx >= stack.length) {
      setImmediate(done, layerError);
      return;
    }

    // max sync stack
    if (++sync > 100) {
      return setImmediate(next, err)
    }

    // get pathname of request
    var path = getPathname(req);

    if (path == null) {
      return done(layerError);
    }

    // find next matching layer
    var layer;
    var match;
    var route;

    while (match !== true && idx < stack.length) {
      layer = stack[idx++];
      match = matchLayer(layer, path);
      route = layer.route;

      if (typeof match !== 'boolean') {
        // hold on to layerError
        layerError = layerError || match;
      }

      if (match !== true) {
        continue;
      }

      if (!route) {
        // process non-route handlers normally
        continue;
      }

      if (layerError) {
        // routes do not match with a pending error
        match = false;
        continue;
      }

      var method = req.method;
      var has_method = route._handles_method(method);

      // build up automatic options response
      if (!has_method && method === 'OPTIONS') {
        appendMethods(options, route._options());
      }

      // don't even bother matching route
      if (!has_method && method !== 'HEAD') {
        match = false;
      }
    }

    // no match
    if (match !== true) {
      return done(layerError);
    }

    // store route for dispatch on change
    if (route) {
      req.route = route;
    }

    // Capture one-time layer values
    req.params = self.mergeParams
      ? mergeParams(layer.params, parentParams)
      : layer.params;
    var layerPath = layer.path;

    // this should be done for the layer
    self.process_params(layer, paramcalled, req, res, function (err) {
      if (err) {
        next(layerError || err)
      } else if (route) {
++        layer.handle_request(req, res, next)
      } else {
        trim_prefix(layer, layerError, layerPath, path)
      }

      sync = 0
    });
  }

  function trim_prefix(layer, layerError, layerPath, path) {
	// 省略
  }
};